Skip to content

Conversation

@jensneuse
Copy link
Member

@jensneuse jensneuse commented Oct 10, 2025

5% improvement on parse & marshal benchmark

Summary by CodeRabbit

  • Refactor

    • Parsing redesigned for faster, more consistent performance with improved memory/cache layout and prioritized common-case paths (strings, objects, arrays).
    • Per-entry key handling reduces unnecessary work and speeds object operations.
  • Bug Fixes

    • More robust whitespace, literal and numeric parsing; fewer slow-paths and improved correctness in edge and concurrent scenarios.
  • Tests

    • Much-expanded unit and benchmark suites; benchmarks now reuse buffers for realistic measurements.

@coderabbitai
Copy link

coderabbitai bot commented Oct 10, 2025

Walkthrough

Removes parser buffering field, restructures parse flow to pass raw input, and reorganizes in-memory layouts (Value, Object, kv) for cache-friendliness. Introduces per-entry keyUnescaped handling, refactors whitespace/unescape logic, updates merge/set flows to unescape on-demand, and expands tests/benchmarks to exercise these paths.

Changes

Cohort / File(s) Change summary
Parser core & layouts
parser.go
Removed Parser.b buffer; Parse/ParseWithArena now pass raw string to internal parse; reordered Value fields to t, s, a, o; changed Object/kv layout and added per-kv keyUnescaped; added cache/branch-prediction comments; reworked parseValue to frequency-ordered switch and inlined branches; improved whitespace fast/slow paths and introduced unescapeKey.
Object merge & update logic
mergevalues.go, update.go
Switched from bulk unescapeKeys to conditional, per-kv unescapeKey during merges and Set/search; tightened fast-path to check kv.keyUnescaped and match without global keysUnescaped guard; new slow-path unescapes individual keys on demand; set new keys with keyUnescaped=true.
Tests & benchmarks
parser_test.go, update_test.go
Benchmarks reuse marshal buffer (out := v.MarshalTo(out[:0])) and add arena parse+get benchmark; large suite of new unit tests covering parse errors, arena/byte parsing, whitespace, escaping/unescaping, numeric edge cases, type conversions, concurrency, Object.Set behaviors, and many edge cases (long keys/values, unicode, escaped keys).
API surface notes
(public types)
No exported function signatures changed. Public type layouts of Value and Object were reordered (field layout change) and Parser lost an internal buffer field.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • feat: improve arena implementation #12 — Modifies parsing and object-merging codepaths with per-entry key unescaping and arena-aware parsing/allocations; strongly related to the per-kv unescape and parse layout changes in this PR.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title succinctly describes the primary change, focusing on optimizations for branch prediction and cache-friendly structures, which are the central modifications of this PR.
✨ Finishing touches
  • 📝 Generate docstrings

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
parser.go (3)

110-116: Avoid hard-coding byte sizes in comments (arch/Go-version dependent).
These “X bytes” claims can mislead across 32/64‑bit and different Go versions/GC. Consider dropping the exact counts or guard them with unsafe.Sizeof in a test/doc.


130-192: Switch-based dispatch is solid; consider removing duplicated NaN handling.
The dedicated 'n' branch handles null and also special-cases nan, but parseRawNumber already accepts ±inf/nan case-insensitively. You can drop the nan sub-branch under 'n' to reduce duplication without changing behavior.

-	case 'n':
-		// null literal - less common
-		if len(s) < len("null") || s[:len("null")] != "null" {
-			// Try parsing NaN
-			if len(s) >= 3 && strings.EqualFold(s[:3], "nan") {
-				v := arena.Allocate[Value](a)
-				v.t = TypeNumber
-				v.s = s[:3]
-				return v, s[3:], nil
-			}
-			return nil, s, fmt.Errorf("unexpected value found: %q", s)
-		}
-		return valueNull, s[len("null"):], nil
+	case 'n':
+		// null literal - less common
+		if len(s) < len("null") || s[:len("null")] != "null" {
+			return nil, s, fmt.Errorf("unexpected value found: %q", s)
+		}
+		return valueNull, s[len("null"):], nil

528-534: Same note as kv: byte-size comments are fragile.
Sizes vary by arch/alignment; prefer qualitative “hot/cold” notes without exact counts.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a4f0844 and fc455b6.

📒 Files selected for processing (2)
  • parser.go (6 hunks)
  • parser_test.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
parser_test.go (2)
handy.go (2)
  • Parse (125-128)
  • ParseWithArena (130-133)
parser.go (1)
  • Parser (38-40)
🔇 Additional comments (6)
parser.go (4)

86-90: LGTM: fast-path check stays branch-friendly.


93-108: Whitespace scan logic is correct and efficient.
The loop correctly skips only JSON‑whitespace and returns empty string if all-whitespace input.


313-326: LGTM: hot checks first improve branch predictability.
Order of '"'/'\' before control chars is sensible.


651-657: Mention Value field reordering in release notes. Repository scan found no unkeyed composite literals of Value or *Value.

parser_test.go (2)

1295-1295: Bench: good practice to reuse buffer and check errors.
The out[:0] pattern avoids reallocs; error checks keep the benchmark honest.

Also applies to: 1300-1301, 1304-1304


1312-1312: Arena bench: LGTM; buffer reuse + arena.Reset() per iter.
Matches the non‑arena benchmark pattern and keeps allocations bounded.

Also applies to: 1317-1318, 1321-1321

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
parser.go (1)

599-621: Bug: Object.Get fails for keys containing escapes; compare logic needs adjustment

Current logic skips raw compare when the input contains '\', then unescapes stored keys but compares against the original (still-escaped) input, causing misses. This breaks lookups like Get("key\with\escapes") as tested.

Fix: always try a raw compare first; if not found and keys aren’t unescaped, unescape once and compare again.

 func (o *Object) Get(key string) *Value {
 
   if o == nil {
     return nil
   }
 
-  if !o.keysUnescaped && strings.IndexByte(key, '\\') < 0 {
-    // Fast path - try searching for the key without object keys unescaping.
-    for _, kv := range o.kvs {
-      if kv.k == key {
-        return kv.v
-      }
-    }
-  }
-
-  // Slow path - unescape object keys.
-  o.unescapeKeys(nil)
-
-  for _, kv := range o.kvs {
-    if kv.k == key {
-      return kv.v
-    }
-  }
+  // First, try raw compare against stored keys (as parsed).
+  for _, kv := range o.kvs {
+    if kv.k == key {
+      return kv.v
+    }
+  }
+  // If not found and keys are not yet unescaped, unescape once and try again.
+  if !o.keysUnescaped {
+    o.unescapeKeys(nil)
+    for _, kv := range o.kvs {
+      if kv.k == key {
+        return kv.v
+      }
+    }
+  }
   return nil
 }
🧹 Nitpick comments (2)
parser_test.go (1)

361-364: Minor test message typos

  • Line 362-364: want text should be "433" (not "443").
  • Line 411-412: math.Inf(-11) should be math.Inf(-1) in the failure message.
- if string(sb) != "433" {
-   t.Fatalf("unexpected value; got %q; want %q", sb, "443")
+ if string(sb) != "433" {
+   t.Fatalf("unexpected value; got %q; want %q", sb, "433")
  }
...
- if !math.IsInf(ninff, -1) {
-   t.Fatalf("unexpected inf_float value: %f. Expecting %f", ninff, math.Inf(-11))
+ if !math.IsInf(ninff, -1) {
+   t.Fatalf("unexpected inf_float value: %f. Expecting %f", ninff, math.Inf(-1))
  }

Also applies to: 409-412

parser.go (1)

363-446: Optional: reduce allocations in unescapeStringBestEffort

When emitting decoded runes, avoid []byte(string(r)) conversions. Use utf8.EncodeRune into a small stack buffer and append, which is allocation-free and cache-friendly.

-            b = arena.SliceAppend(a, b, []byte(string(rune(x)))...)
+            var tmp [utf8.UTFMax]byte
+            n := utf8.EncodeRune(tmp[:], rune(x))
+            b = arena.SliceAppend(a, b, tmp[:n]...)
...
-            r := utf16.DecodeRune(rune(x), rune(x1))
-            b = arena.SliceAppend(a, b, []byte(string(r))...)
+            r := utf16.DecodeRune(rune(x), rune(x1))
+            var tmp2 [utf8.UTFMax]byte
+            n2 := utf8.EncodeRune(tmp2[:], r)
+            b = arena.SliceAppend(a, b, tmp2[:n2]...)

(Requires import "unicode/utf8".)

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fc455b6 and 14fea05.

📒 Files selected for processing (2)
  • parser.go (7 hunks)
  • parser_test.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
parser_test.go (3)
handy.go (5)
  • Parse (125-128)
  • ParseWithArena (130-133)
  • GetBool (95-103)
  • ParseBytesWithArena (155-158)
  • GetInt (59-67)
fastfloat/parse.go (1)
  • Parse (359-508)
parser.go (9)
  • Parser (38-39)
  • NewParseError (24-30)
  • ParseError (13-15)
  • Type (701-701)
  • TypeObject (708-708)
  • TypeArray (711-711)
  • MaxDepth (116-116)
  • Object (527-531)
  • Value (648-654)
parser.go (1)
handy.go (1)
  • ParseWithArena (130-133)
🔇 Additional comments (9)
parser_test.go (5)

1295-1305: Benchmarks: reusing marshal buffer and checking errors is solid

Good changes: capturing parse result, checking errors, and reusing out[:0] prevents allocs and skew. LGTM.

Also applies to: 1312-1322


1326-1353: ParseError tests validate nil receiver and wrapping behavior

Covers nil/new errors and nil receiver method. Matches parser.go’s Error() semantics. LGTM.


1354-1387: Arena-based ParseWithArena tests

Covers simple and nested cases; aligns with ParseWithArena signature. LGTM.


1390-1419: Arena ParseBytesWithArena tests

Exercising byte path for arrays/objects is useful; matches handy.go wrappers. LGTM.


1421-1452: skipWSSlow coverage

Good coverage of allowed JSON whitespace and empty input. Matches implementation. LGTM.

parser.go (4)

47-52: Parse/ParseWithArena delegation

Simple, clear delegation to unified parse. LGTM.


81-87: Whitespace skipping path is correct for JSON’s allowed whitespace

Fast-path check and slow-path scanning over SP, LF, TAB, CR match RFC 8259. Tests cover this. LGTM.

Also applies to: 89-105


127-189: parseValue switch: ordering and error texts look good

Branching for string/object/array/literals with numbers in default is reasonable. Eager unescape for strings is consistent with MarshalTo. LGTM.

If strict JSON compliance is required, note plus-sign-prefixed numbers (“+123”) are accepted by parseRawNumber and may parse; confirm this is intended.


309-323: hasSpecialChars micro-optimizations

Reordered checks for '"' and '\' before control range is sound. LGTM.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
parser.go (1)

596-621: Critical: Object.Get slow path fails to unescape input key.

The slow path unescapes stored keys but compares them against the original escaped input, so keys containing escape sequences never match.

Example:

  • JSON: {"key\\with\\escapes": "value"}
  • Stored key (raw): "key\\with\\escapes" (with double backslashes, keyUnescaped=false)
  • Call Get("key\\with\\escapes") (input with double backslashes)
  • Fast path skipped (input contains \)
  • Slow path: unescapes stored key to "key\with\escapes" (single backslashes), compares with input "key\\with\\escapes" (double backslashes) → no match

Apply this diff to unescape the input key before comparison in the slow path:

 	// Slow path - unescape keys as needed and search
+	unescapedKey := unescapeStringBestEffort(nil, key)
 	for _, kv := range o.kvs {
 		if !kv.keyUnescaped {
 			o.unescapeKey(nil, kv)
 		}
-		if kv.k == key {
+		if kv.k == unescapedKey {
 			return kv.v
 		}
 	}

Note: This issue is already flagged in a previous review comment on parser_test.go lines 1610-1670 and remains unresolved.

🧹 Nitpick comments (2)
update_test.go (1)

123-465: Excellent test coverage for Object.Set.

The comprehensive test suite covers nil handling, escape sequences, special characters, unicode, and various value types. The tests effectively validate the per-entry key handling introduced in this PR.

Note: Line 178 directly checks the internal keyUnescaped flag. While this documents expected behavior, consider whether testing only public behavior (via Get operations) would be more maintainable if the internal implementation changes.

update.go (1)

15-23: Fast path logic is correct but subtle.

The fast path condition !kv.keyUnescaped && kv.k == key only matches keys that haven't been unescaped yet (i.e., keys from parsed JSON). Keys added via Set have keyUnescaped=true and fall through to the slow path. This is correct but could benefit from a clarifying comment.

Consider adding a comment explaining why the fast path only works for unescaped keys:

 	if strings.IndexByte(key, '\\') < 0 {
-		// Fast path - try searching for the key without unescaping
+		// Fast path - for keys without backslashes, try direct comparison with unescaped stored keys
+		// (keys from parsed JSON have keyUnescaped=false until first access)
 		for i, kv := range o.kvs {
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5d491a9 and e9aa7a7.

📒 Files selected for processing (5)
  • mergevalues.go (1 hunks)
  • parser.go (10 hunks)
  • parser_test.go (1 hunks)
  • update.go (3 hunks)
  • update_test.go (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
parser.go (1)
handy.go (1)
  • ParseWithArena (130-133)
update_test.go (2)
parser.go (9)
  • Object (528-531)
  • Parser (38-39)
  • Type (701-701)
  • TypeNull (705-705)
  • TypeString (714-714)
  • TypeNumber (717-717)
  • TypeTrue (720-720)
  • TypeArray (711-711)
  • TypeObject (708-708)
handy.go (2)
  • MustParse (139-145)
  • Parse (125-128)
parser_test.go (2)
handy.go (5)
  • Parse (125-128)
  • ParseWithArena (130-133)
  • GetBool (95-103)
  • ParseBytesWithArena (155-158)
  • GetInt (59-67)
parser.go (9)
  • Parser (38-39)
  • NewParseError (24-30)
  • ParseError (13-15)
  • Type (701-701)
  • TypeObject (708-708)
  • TypeArray (711-711)
  • MaxDepth (117-117)
  • Object (528-531)
  • Value (648-654)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Build and test (go 1.25 / windows-latest)
🔇 Additional comments (12)
mergevalues.go (1)

38-43: LGTM: Per-entry key unescaping aligns with optimization goals.

The change from bulk unescaping to per-entry conditional unescaping during object merges is correct and aligns with the broader per-entry key handling strategy across the codebase.

update_test.go (1)

467-577: LGTM: Edge case coverage is thorough.

The edge case tests for repeated sets, set-after-get scenarios, and very long keys/values provide good coverage of potential issues.

update.go (1)

67-75: LGTM: Set method correctly handles per-entry unescaping.

The Set method properly unescapes existing keys before comparison and marks new keys as already unescaped since they come from user input.

Also applies to: 81-81

parser_test.go (3)

1295-1305: LGTM: Benchmark improvements align with optimization goals.

Adding the marshal step to benchmarks (reusing the out buffer) provides a more realistic measurement of the full parse-marshal cycle and exercises the memory optimization changes.

Also applies to: 1308-1324


1326-1354: LGTM: New benchmark covers common usage pattern.

The new BenchmarkParseArenaAndGet benchmark tests a realistic scenario of parsing, accessing multiple keys, and marshaling, which helps validate the performance impact of the per-entry key handling.


1357-2104: LGTM: Comprehensive edge case test coverage.

The extensive new test suites provide thorough coverage of edge cases including:

  • ParseError handling
  • Arena-based parsing
  • Whitespace skipping
  • String escaping/unescaping edge cases
  • Numeric boundary conditions
  • Type conversion errors
parser.go (6)

46-52: LGTM: Clean refactoring unifies parse paths.

Consolidating Parse and ParseWithArena to call a common parse helper eliminates code duplication and makes the arena parameter consistent.


82-105: LGTM: Whitespace skipping optimization is sound.

The fast path early return and branch prediction optimizations in skipWSSlow should improve performance for common cases while maintaining correctness.


107-114: LGTM: Per-entry key state aligns with optimization goals.

Adding the keyUnescaped flag to the kv struct enables lazy per-entry unescaping and reduces unnecessary work during parsing and lookups.


128-191: LGTM: Frequency-ordered dispatch should improve branch prediction.

Reordering the switch cases by frequency (strings/objects/arrays first, literals last) should improve CPU branch prediction and reduce mispredictions in typical JSON workloads.


310-324: LGTM: Cache-friendly layout and documentation improvements.

The struct reordering (hot fields first) and detailed comments about memory layout should improve cache locality. The MarshalTo optimization to check keyUnescaped before escaping is correct.

Also applies to: 526-531, 541-556, 646-654


577-584: LGTM: Dedicated unescapeKey method improves code clarity.

Extracting the unescape logic into a dedicated method makes the per-entry unescaping pattern consistent across Get, Visit, Del, and Set operations.

@jensneuse jensneuse merged commit 0454c46 into master Oct 25, 2025
5 checks passed
@jensneuse jensneuse deleted the feat/improve-memory-layout branch October 25, 2025 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants